package com.codequirks.xmlbeanutils;

import java.io.StringReader;
import java.io.IOException;
import java.util.*;

import javax.xml.parsers.ParserConfigurationException;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.DocumentBuilder;

import org.apache.commons.beanutils.DynaClass;
import org.apache.commons.beanutils.DynaBean;

import org.apache.commons.beanutils.LazyDynaClass;
import org.w3c.dom.Attr;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.NodeList;
import org.w3c.dom.Node;

import org.xml.sax.SAXException;
import org.xml.sax.InputSource;

/**
 * Provides an interface into the properties of an XML Document with minimal configuration.
 * <p/>
 * Passing valid XML into this class will create an object which can be used to access attributes,
 * children, and the text content of the root node. Accessing the children will return
 * <code>XmlDynaBean</code> objects which represent the children, each of which can be
 * queried in the same way.
 * <p/>
 * This class allows tooling of property access with the <code>NodePropertyAccessor</code> object.
 * This is necessary because some legacy XML parsers do not properly support all of the new
 * methods in the {@link Node} interface. See the Javadoc of {@link NodePropertyAccessor}
 * for more on the topic.
 *
 * @author Travis Calder <travis.calder@gmail.com>
 * @since 1.0.0
 */
public class XmlDynaBean implements DynaBean {

    /**
     * Key for get() requests of the node name.
     * @since 1.0.0
     */
    public static final String NODE_NAME = "name()";

    /**
     * Key for get() requests for all children
     * @since 1.0.0
     */
    public static final String ALL_CHILDREN = "*";

    /**
     * Key for get() requests of node text content
     * @since 1.0.0
     */
    public static final String TEXT_CONTENT = "text()";

    private final Node xmlDocument;

    private Map<String, List<XmlDynaBean>> children = new HashMap<String,List<XmlDynaBean>>();
    private Map<String, String> attributes = new HashMap<String,String>();
    private List<XmlDynaBean> allChildren = new ArrayList<XmlDynaBean>();
    private LazyDynaClass dynaClass;
    private NodePropertyAccessor propertyAccessor;

    /**
     * Parses an XML String to create an XmlDynaBean tree representing the XML.
     *
     * @param xml String containing well-formed XML. There can only be one root node, and all special characters should be escaped or properly encoded.
     * @throws ParserConfigurationException If your {@link DocumentBuilderFactory} is not configured correctly
     * @throws IOException   If an I/O error occurs reading or parsing of the XML String
     * @throws SAXException If the XML document cannot be parsed
     * @since 1.0.0
     */
    public XmlDynaBean(String xml) throws ParserConfigurationException, IOException, SAXException {
        this.propertyAccessor = new DefaultNodePropertyAccessor();
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder = factory.newDocumentBuilder();
        xmlDocument = builder.parse(new InputSource(new StringReader(xml))).getFirstChild();
    }

    /**
     * Creates an XmlDynaBean tree representing the Node. This currently does not work with parent nodes, since
     * XmlDynaBean does not support querying the parent of an XmlDynaBean.
     *
     * @param dom Node containing the XML tree which this XmlDynaBean will represent
     * @since 1.0.0
     */
    public XmlDynaBean(Node dom) {
        this.propertyAccessor = new DefaultNodePropertyAccessor();
        xmlDocument = dom;
    }


    /**
     * Parses an XML String to create an XmlDynaBean tree representing the XML.
     * <p>
     * Provides the opportunity to intercept and tool property accesses during initialization tree.
     *
     * @param propertyAccessor A property accessor for initialization, discarded after construction is complete
     * @param xml String containing well-formed XML. There can only be one root node, and all special characters should be escaped or properly encoded.
     * @throws ParserConfigurationException If your {@link DocumentBuilderFactory} is not configured correctly
     * @throws IOException   If an I/O error occurs reading or parsing of the XML String
     * @throws SAXException If the XML document cannot be parsed
     * @since 1.0.1
     * @see NodePropertyAccessor
     */
    public XmlDynaBean(NodePropertyAccessor propertyAccessor, String xml) throws ParserConfigurationException, IOException, SAXException {
        this.propertyAccessor = propertyAccessor;
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder = factory.newDocumentBuilder();
        xmlDocument = builder.parse(new InputSource(new StringReader(xml))).getFirstChild();
    }

    /**
     * Creates an XmlDynaBean tree representing the Node. This currently does not work with parent nodes, since
     * XmlDynaBean does not support querying the parent of an XmlDynaBean.
     * <p>
     * Provides the opportunity to intercept and tool property accesses during initialization tree.
     *
     * @param propertyAccessor A property accessor for initialization, discarded after construction is complete
     * @param dom Node containing the XML tree which this XmlDynaBean will represent
     * @since 1.0.1
     * @see NodePropertyAccessor
     */
    public XmlDynaBean(NodePropertyAccessor propertyAccessor, Node dom) {
        this.propertyAccessor = propertyAccessor;
        xmlDocument = dom;
    }

    @Override
    public DynaClass getDynaClass() {
        if(dynaClass == null) {
            prepareDynaClass();
        }
        return dynaClass;
    }

    /**
     * Provides keyed access to the properties of the XmlDynaBean.
     * <p/>
     * Examples:<br/>
     * <code>get(TEXT_CONTENT)</code> returns the text of a node<br/>
     * <code>get(NODE_NAME)</code> returns the name of a node<br/>
     * <code>get(ALL_CHILDREN)</code> returns the children of a node<br/>
     * <code>get("@attr")</code> returns the value of the attribute named "attr" for this node<br/>
     * <code>get("node")</code> returns any children named "node" for this node<br/>
     *
     * @param key The key to look up data by
     * @return Either a <code>String</code> representing data, or a <code>List<XmlDynaBean</code> representing some set of children
     * @since 1.0.0
     */
    @Override
    public Object get(String key) {
        if(key == null)
            throw new IllegalArgumentException("Cannot look up NULL property on XmlDynaBean.");

        if(ALL_CHILDREN.equals(key)) {
            if(children.isEmpty()) {
                prepareChildren();
            }
            return allChildren;
        }

        if(TEXT_CONTENT.equals(key))
            return propertyAccessor.getNodeText(xmlDocument);

        if(NODE_NAME.equals(key))
            return propertyAccessor.getNodeName(xmlDocument);

        if(key.startsWith("@")) {
            String attributeName = key.substring(1);
            return getAttribute(attributeName);
        }

        return getChild(key);
    }

    /**
     * Provides indexed access to the children of the XmlDynaBean. This method is zero-indexed
     * <p/>
     * Examples:<br/>
     * <code>get(ALL_CHILDREN, 0)</code> returns the 1st child of a node<br/>
     * <code>get("node", 3)</code> returns the fourth node of name "node"<br/>
     * <p>
     * <code>get(node, index)</code> is equivalent to <code>get(node).get(index)</code>
     *
     * @param node The key to look children up by. See the examples above.
     * @param index The index of the child to return from the set.
     * @return The <code>XmlDynaBean</code> represented by the query
     * @throws IndexOutOfBoundsException if the index is out of range, or node represents no children
     * @since 1.0.0
     */
    @Override
    public XmlDynaBean get(String node, int index) {
        Object o = get(node);
        if( !(o instanceof List) ) {
            throw new IndexOutOfBoundsException("Query \"" + node + "\" returned no results.");
        }
        return (XmlDynaBean) ((List) o).get(index);
    }

    /**
     * Provides keyed access to properties from a named node. If a <code>get(node)</code> would
     * return more than one result, <code>get(node, key)</code> will return properties from the first node
     * the set would return.
     * <p/>
     * Examples:<br/>
     * <code>get("child", TEXT_CONTENT)</code> returns text content of the first node named "child"<br/>
     * <code>get("child", "grandchild")</code> returns the children named "grandchild" of the first node named "child"<br/>
     * <p>
     * <code>get(node, key)</code> is equivalent to <code>get(node).get(0).get(key)</code>
     *
     * @param node The key to look children up by. See the examples above.
     * @param key The key to look up on the first node from the resulting set
     * @return A <code>String</code> when looking up properties, or a <code>List<XmlDynaBean></code> when looking up grand-children
     * @throws IndexOutOfBoundsException if node represents no children
     * @since 1.0.0
     */
    @Override
    public Object get(String node, String key) {
        Object o = get(node);
        if( !(o instanceof List) ) {
            throw new IndexOutOfBoundsException("Query \"" + node + "\" returned no results.");
        }
        XmlDynaBean dynaBean =  (XmlDynaBean) ((List) o).get(0);
        return dynaBean.get(key);
    }

    /**
     * Determines if a query to <code>get(node, key)</code> would return a value or not.
     *
     * @param node The key to look children up by. See the examples above.
     * @param key The key to look up on the first node from the resulting set
     * @return True if <code>get(node, key)</code> would return a non-null value, otherwise false
     * @since 1.0.0
     */
    @Override
    public boolean contains(String node, String key) {
        try {
            return get(node, key) != null;
        } catch(IndexOutOfBoundsException e) {
            return false;
        }
    }

    /**
     * Method is not supported. An XmlDynaBean is immutable.
     *
     * @param s Not used
     * @param s1 Not used
     * @throws UnsupportedOperationException XmlDynaBeans are immutable
     * @since 1.0.0
     */
    @Override
    public void remove(String s, String s1) {
        throw new UnsupportedOperationException("XmlDynaBean does not allow keyed properties to be removed.");
    }

    /**
     * Method is not supported. An XmlDynaBean is immutable.
     *
     * @param s Not used
     * @param o Not used
     * @throws UnsupportedOperationException XmlDynaBeans are immutable
     * @since 1.0.0
     */
    @Override
    public void set(String s, Object o) {
        throw new UnsupportedOperationException("XmlDynaBean does not allow properties to be set.");
    }

    /**
     * Method is not supported. An XmlDynaBean is immutable.
     *
     * @param s Not used
     * @param i Not used
     * @param o Not used
     * @throws UnsupportedOperationException XmlDynaBeans are immutable
     * @since 1.0.0
     */
    @Override
    public void set(String s, int i, Object o) {
        throw new UnsupportedOperationException("XmlDynaBean does not allow indexed properties to be set.");
    }

    /**
     * Method is not supported. An XmlDynaBean is immutable.
     *
     * @param s Not used
     * @param s1 Not used
     * @param o Not used
     * @throws UnsupportedOperationException XmlDynaBeans are immutable
     * @since 1.0.0
     */
    @Override
    public void set(String s, String s1, Object o) {
        throw new UnsupportedOperationException("XmlDynaBean does not allow keyed properties to be set.");
    }

    private void prepareAttributes() {
        attributes = new HashMap<String,String>();
        NamedNodeMap nodeAttributes = propertyAccessor.getAllAttributes(xmlDocument);

        if(nodeAttributes == null) {
            return;
        }

        for(int i = 0; i < nodeAttributes.getLength(); i++) {
            Attr attribute = (Attr) nodeAttributes.item(i);
            attributes.put(propertyAccessor.getNodeName(attribute), propertyAccessor.getAttributeContents(attribute));
        }
    }

    private void prepareChildren() {
        children = new HashMap<String, List<XmlDynaBean>>();
        allChildren = new ArrayList<XmlDynaBean>();
        NodeList childNodes = propertyAccessor.getAllChildren(xmlDocument);
        for(int i = 0; i < childNodes.getLength(); i++) {
            Node child = childNodes.item(i);
            String nodeName = propertyAccessor.getNodeName(child);

            if(nodeName != null) {
                if(!children.containsKey(nodeName)) {
                    children.put(nodeName, new ArrayList<XmlDynaBean>());
                }
                XmlDynaBean dynaBean = newXmlDynaBean(child);
                children.get(nodeName).add(dynaBean);
                allChildren.add(dynaBean);
            }
        }
    }

    private XmlDynaBean newXmlDynaBean(Node node) {
        if(propertyAccessor == null) {
            return new XmlDynaBean(node);
        } else {
            return new XmlDynaBean(propertyAccessor, node);
        }
    }

    private void prepareDynaClass() {
        dynaClass = new LazyDynaClass();

        prepareAttributes();
        if(children.isEmpty()) {
            prepareChildren();
        }

        dynaClass.add("name()", String.class);
        dynaClass.add("text()", String.class);
        dynaClass.add("*", List.class);
        for(String attribute : attributes.keySet()) {
            dynaClass.add("@" + attribute, String.class);
        }
        for(String child : children.keySet()) {
            dynaClass.add(child, List.class);
        }
    }

    private Object getChild(String key) {
        if(children.isEmpty()) {
            prepareChildren();
        }
        return children.get(key);
    }

    private Object getAttribute(String attributeName) {
        if(!attributes.containsKey(attributeName)) {
            loadAttribute(attributeName);
        }
        return attributes.get(attributeName);
    }

    private void loadAttribute(String attributeName) {
        Attr attribute = (Attr) xmlDocument.getAttributes().getNamedItem(attributeName);
        if(attribute != null) {
            String attributeValue = propertyAccessor.getAttributeContents(attribute);
            attributes.put(attributeName, attributeValue);
        }
    }
}
